SPB Git

Repository Public

route.ts 3.0 KB 61 lines typescript

1import { createReadStream } from "node:fs";2import { stat } from "node:fs/promises";3import { Readable } from "node:stream";4import { sql } from "@/lib/db";5import { currentUser } from "@/lib/auth";6import { MIME, storage, verifySignedUrl } from "@/lib/storage";78/**9 * Media delivery with HTTP range support.10 * Public only while the asset belongs to a published film; otherwise a signed URL or a studio session is required.11 */12const publicCache = new Map<string, { ok: boolean; at: number }>();13const TTL = 20_000;   // a takedown stops delivery within 20 seconds1415async function isPublic(sha: string): Promise<boolean> {16  const hit = publicCache.get(sha);17  if (hit && Date.now() - hit.at < TTL) return hit.ok;18  const [r] = await sql<{ ok: boolean }[]>`SELECT bool_or(public) AS ok FROM assets WHERE sha256 = ${sha}`;19  publicCache.set(sha, { ok: !!r?.ok, at: Date.now() });20  if (publicCache.size > 2000) publicCache.clear();21  return !!r?.ok;22}2324export async function GET(req: Request, ctx: { params: Promise<{ file: string }> }) {25  const { file } = await ctx.params;26  const m = /^([a-f0-9]{64})\.(mp4|jpg|png|webp|mp3|vtt)$/.exec(file);27  if (!m) return new Response("Not found", { status: 404 });28  const [, sha, ext] = m;29  const url = new URL(req.url);30  const open = await isPublic(sha);31  if (!open && !verifySignedUrl(sha, ext, url.searchParams.get("exp"), url.searchParams.get("sig")) && !(await currentUser())) {32    return new Response("Not found", { status: 404 });33  }34  const path = storage().pathOf(sha, ext);35  let size: number;36  try { size = (await stat(path)).size; } catch { return new Response("Not found", { status: 404 }); }3738  const headers: Record<string, string> = {39    "Content-Type": MIME[ext] ?? "application/octet-stream",40    "Accept-Ranges": "bytes",41    ETag: `"${sha}"`,42    "Cache-Control": open ? "public, max-age=3600, stale-while-revalidate=86400" : "private, no-store",43    "X-Content-Type-Options": "nosniff",44  };45  if (url.searchParams.get("download") === "1" && open) headers["Content-Disposition"] = `attachment; filename="clip-${sha.slice(0, 10)}.${ext}"`;46  if (req.headers.get("if-none-match") === `"${sha}"`) return new Response(null, { status: 304, headers });4748  const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.get("range") ?? "");49  if (range && (range[1] || range[2])) {50    let start = range[1] ? Number(range[1]) : size - Number(range[2]);51    let end = range[1] && range[2] ? Number(range[2]) : size - 1;52    if (!range[1]) start = Math.max(0, start);53    end = Math.min(end, size - 1);54    if (start > end || start >= size) return new Response(null, { status: 416, headers: { "Content-Range": `bytes */${size}` } });55    return new Response(Readable.toWeb(createReadStream(path, { start, end })) as ReadableStream, {56      status: 206, headers: { ...headers, "Content-Range": `bytes ${start}-${end}/${size}`, "Content-Length": String(end - start + 1) },57    });58  }59  return new Response(Readable.toWeb(createReadStream(path)) as ReadableStream, { status: 200, headers: { ...headers, "Content-Length": String(size) } });60}61
Files main
Menu

Theme